//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import SwiftParser
import SwiftSyntax
import SwiftSyntaxBuilder

/// Add a package dependency to a package manifest's source code.
@_spi(PackageRefactor)
public struct AddPackageDependency: ManifestEditRefactoringProvider {
  public struct Context {
    public var dependency: PackageDependency

    public init(dependency: PackageDependency) {
      self.dependency = dependency
    }
  }

  /// The set of argument labels that can occur after the "dependencies"
  /// argument in the Package initializers.
  private static let argumentLabelsAfterDependencies: Set<String> = [
    "targets",
    "swiftLanguageVersions",
    "cLanguageStandard",
    "cxxLanguageStandard",
  ]

  /// Produce the set of source edits needed to add the given package
  /// dependency to the given manifest file.
  public static func manifestRefactor(
    syntax manifest: SourceFileSyntax,
    in context: Context
  ) throws -> PackageEdit {
    let dependency = context.dependency
    guard let packageCall = manifest.findCall(calleeName: "Package") else {
      throw ManifestEditError.cannotFindPackage
    }

    guard
      try !dependencyAlreadyAdded(
        dependency,
        in: packageCall
      )
    else {
      return PackageEdit(manifestEdits: [])
    }

    let newPackageCall = try addPackageDependencyLocal(
      dependency,
      to: packageCall
    )

    return PackageEdit(
      manifestEdits: [
        .replace(packageCall, with: newPackageCall.description)
      ]
    )
  }

  /// Return `true` if the dependency already exists in the manifest, otherwise return `false`.
  /// Throws an error if a dependency already exists with the same id or url, but different arguments.
  private static func dependencyAlreadyAdded(
    _ dependency: PackageDependency,
    in packageCall: FunctionCallExprSyntax
  ) throws -> Bool {
    let dependencySyntax = dependency.asSyntax()
    guard let dependencyFnSyntax = dependencySyntax.as(FunctionCallExprSyntax.self) else {
      throw ManifestEditError.cannotFindPackage
    }

    guard
      let id = dependencyFnSyntax.arguments.first(where: {
        $0.label?.text == "url" || $0.label?.text == "id" || $0.label?.text == "path"
      })
    else {
      throw ManifestEditError.malformedManifest(error: "missing id or url argument in dependency syntax")
    }

    if let existingDependencies = packageCall.findArgument(labeled: "dependencies") {
      // If we have an existing dependencies array, we need to check if
      // it's already added.
      if let expr = existingDependencies.expression.as(ArrayExprSyntax.self) {
        // Iterate through existing dependencies and look for an argument that matches
        // either the `id` or `url` argument of the new dependency.
        let existingArgument = expr.elements.first { elem in
          if let funcExpr = elem.expression.as(FunctionCallExprSyntax.self) {
            return funcExpr.arguments.contains {
              $0.trimmedDescription == id.trimmedDescription
            }
          }
          return true
        }

        if let existingArgument {
          let normalizedExistingArgument = existingArgument.detached.with(\.trailingComma, nil)
          // This exact dependency already exists, return false to indicate we should do nothing.
          if normalizedExistingArgument.trimmedDescription == dependencySyntax.trimmedDescription {
            return true
          }
          throw ManifestEditError.existingDependency(dependencyName: dependency.identifier)
        }
      }
    }
    return false
  }

  /// Implementation of adding a package dependency to an existing call.
  static func addPackageDependencyLocal(
    _ dependency: PackageDependency,
    to packageCall: FunctionCallExprSyntax
  ) throws -> FunctionCallExprSyntax {
    try packageCall.appendingToArrayArgument(
      label: "dependencies",
      labelsAfter: Self.argumentLabelsAfterDependencies,
      newElement: dependency.asSyntax()
    )
  }
}

fileprivate extension PackageDependency {
  var identifier: String {
    switch self {
    case .sourceControl(let info):
      return info.location
    case .fileSystem(let info):
      return info.path
    case .registry(let info):
      return info.identity
    }
  }
}
